import Foundation
import SourceKittenFramework
import SwiftLintCore

// swiftlint:disable file_length

private let warnSourceKitFailedOnceImpl: Void = {
    Issue.genericWarning("SourceKit-based rules will be skipped because sourcekitd has failed.").print()
}()

private func warnSourceKitFailedOnce() {
    _ = warnSourceKitFailedOnceImpl
}

private struct LintResult {
    let violations: [StyleViolation]
    let ruleTime: (id: String, time: Double)?
    let deprecatedToValidIDPairs: [(String, String)]
}

private extension Rule {
    func superfluousDisableCommandViolations(regions: [Region],
                                             superfluousDisableCommandRule: SuperfluousDisableCommandRule?,
                                             allViolations: [StyleViolation]) -> [StyleViolation] {
        guard regions.isNotEmpty, let superfluousDisableCommandRule else {
            return []
        }

        let regions = regions.perIdentifierRegions

        let regionsDisablingSuperfluousDisableRule = regions.filter { region in
            region.isRuleDisabled(superfluousDisableCommandRule)
        }

        var superfluousDisableCommandViolations = [StyleViolation]()
        for region in regions {
            if regionsDisablingSuperfluousDisableRule.contains(where: { $0.contains(region.start) }) {
                continue
            }
            guard let disabledRuleIdentifier = region.disabledRuleIdentifiers.first else {
                continue
            }
            guard !isEnabled(in: region, for: disabledRuleIdentifier.stringRepresentation) else {
                continue
            }
            var disableCommandValid = false
            for violation in allViolations where region.contains(violation.location) {
                if canBeDisabled(violation: violation, by: disabledRuleIdentifier) {
                    disableCommandValid = true
                    break
                }
            }
            if !disableCommandValid {
                let reason = superfluousDisableCommandRule.reason(
                    forRuleIdentifier: disabledRuleIdentifier.stringRepresentation
                )
                superfluousDisableCommandViolations.append(
                    StyleViolation(
                        ruleDescription: type(of: superfluousDisableCommandRule).description,
                        severity: superfluousDisableCommandRule.configuration.severity,
                        location: region.start,
                        reason: reason
                    )
                )
            }
        }
        return superfluousDisableCommandViolations
    }

    func shouldRun(onFile file: SwiftLintFile) -> Bool {
        // We shouldn't lint if the current Swift version is not supported by the rule
        guard SwiftVersion.current >= Self.description.minSwiftVersion else {
            return false
        }

        // Empty files shouldn't trigger violations if `shouldLintEmptyFiles` is `false`
        if file.isEmpty, !shouldLintEmptyFiles {
            return false
        }

        if requiresSourceKit {
            // If SourceKit is disabled, we skip running the rule and post a warning once.
            if Request.disableSourceKit {
                notifyRuleDisabledOnce()
                return false
            }
            // Only check `sourcekitdFailed` if the rule requires SourceKit. This avoids triggering SourceKit
            // initialization for effectively SourceKit-free rules.
            if file.sourcekitdFailed {
                warnSourceKitFailedOnce()
                return false
            }
            return true
        }
        return true
    }

    // As we need the configuration to get custom identifiers.
    // swiftlint:disable:next function_parameter_count
    func lint(file: SwiftLintFile,
              regions: [Region],
              benchmark: Bool,
              storage: RuleStorage,
              superfluousDisableCommandRule: SuperfluousDisableCommandRule?,
              compilerArguments: [String]) -> LintResult {
        let ruleID = Self.identifier

        // Wrap entire lint process including shouldRun check in rule context
        return CurrentRule.$identifier.withValue(ruleID) {
            guard shouldRun(onFile: file) else {
                return LintResult(violations: [], ruleTime: nil, deprecatedToValidIDPairs: [])
            }

            return performLint(
                file: file,
                regions: regions,
                benchmark: benchmark,
                storage: storage,
                superfluousDisableCommandRule: superfluousDisableCommandRule,
                compilerArguments: compilerArguments
            )
        }
    }

    // swiftlint:disable:next function_parameter_count
    private func performLint(file: SwiftLintFile,
                             regions: [Region],
                             benchmark: Bool,
                             storage: RuleStorage,
                             superfluousDisableCommandRule: SuperfluousDisableCommandRule?,
                             compilerArguments: [String]) -> LintResult {
        let ruleID = Self.identifier

        let violations: [StyleViolation]
        let ruleTime: (String, Double)?
        if benchmark {
            let start = Date()
            violations = validate(file: file, using: storage, compilerArguments: compilerArguments)
            ruleTime = (ruleID, -start.timeIntervalSinceNow)
        } else {
            violations = validate(file: file, using: storage, compilerArguments: compilerArguments)
            ruleTime = nil
        }

        let (disabledViolationsAndRegions, enabledViolationsAndRegions) = violations.map { violation in
            (violation, regions.first { $0.contains(violation.location) })
        }.partitioned { violation, region in
            if let region {
                return isEnabled(in: region, for: violation.ruleIdentifier)
            }
            return true
        }

        let customRulesIDs: [String] = {
            guard let customRules = self as? CustomRules else {
                return []
            }
            return customRules.customRuleIdentifiers
        }()
        let ruleIDs = Self.description.allIdentifiers +
            customRulesIDs +
            (superfluousDisableCommandRule.map({ type(of: $0) })?.description.allIdentifiers ?? []) +
            [RuleIdentifier.all.stringRepresentation]
        let ruleIdentifiers = Set(ruleIDs.map { RuleIdentifier($0) })

        let superfluousDisableCommandViolations = superfluousDisableCommandViolations(
            regions: regions.count > 1 ? file.regions(restrictingRuleIdentifiers: ruleIdentifiers) : regions,
            superfluousDisableCommandRule: superfluousDisableCommandRule,
            allViolations: violations
        )

        let enabledViolations: [StyleViolation]
        if file.contents.hasPrefix("#!") { // if a violation happens on the same line as a shebang, ignore it
            enabledViolations = enabledViolationsAndRegions.compactMap { violation, _ in
                if violation.location.line == 1 { return nil }
                return violation
            }
        } else {
            enabledViolations = enabledViolationsAndRegions.map(\.0)
        }
        let deprecatedToValidIDPairs = disabledViolationsAndRegions.flatMap { _, region -> [(String, String)] in
            let identifiers = region?.deprecatedAliasesDisabling(rule: self) ?? []
            return identifiers.map { ($0, ruleID) }
        }

        return LintResult(violations: enabledViolations + superfluousDisableCommandViolations,
                          ruleTime: ruleTime,
                          deprecatedToValidIDPairs: deprecatedToValidIDPairs)
    }
}

private extension [Region] {
    // Normally regions correspond to changes in the set of enabled rules. To detect superfluous disable command
    // rule violations effectively, we need individual regions for each disabled rule identifier.
    var perIdentifierRegions: [Region] {
        guard isNotEmpty else {
            return []
        }

        var convertedRegions = [Region]()
        var startMap: [RuleIdentifier: Location] = [:]
        var lastRegionEnd: Location?

        for region in self {
            let ruleIdentifiers = startMap.keys.sorted()
            for ruleIdentifier in ruleIdentifiers where !region.disabledRuleIdentifiers.contains(ruleIdentifier) {
                if let lastRegionEnd, let start = startMap[ruleIdentifier] {
                    let newRegion = Region(start: start, end: lastRegionEnd, disabledRuleIdentifiers: [ruleIdentifier])
                    convertedRegions.append(newRegion)
                    startMap[ruleIdentifier] = nil
                }
            }
            for ruleIdentifier in region.disabledRuleIdentifiers where startMap[ruleIdentifier] == nil {
                startMap[ruleIdentifier] = region.start
            }
            if region.disabledRuleIdentifiers.isEmpty {
                convertedRegions.append(region)
            }
            lastRegionEnd = region.end
        }

        let end = Location(file: first?.start.file, line: .max, character: .max)
        for ruleIdentifier in startMap.keys.sorted() {
            if let start = startMap[ruleIdentifier] {
                let newRegion = Region(start: start, end: end, disabledRuleIdentifiers: [ruleIdentifier])
                convertedRegions.append(newRegion)
                startMap[ruleIdentifier] = nil
            }
        }

        return convertedRegions.sorted {
            if $0.start == $1.start {
                if let lhsDisabledRuleIdentifier = $0.disabledRuleIdentifiers.first,
                   let rhsDisabledRuleIdentifier = $1.disabledRuleIdentifiers.first {
                    return lhsDisabledRuleIdentifier < rhsDisabledRuleIdentifier
                }
            }
            return $0.start < $1.start
        }
    }
}

/// Represents a file that can be linted for style violations and corrections after being collected.
public struct Linter {
    /// The file to lint with this linter.
    public let file: SwiftLintFile
    /// Whether or not this linter will be used to collect information from several files.
    public var isCollecting: Bool
    fileprivate let rules: [any Rule]
    fileprivate let cache: LinterCache?
    fileprivate let configuration: Configuration
    fileprivate let compilerArguments: [String]

    /// Creates a `Linter` by specifying its properties directly.
    ///
    /// - parameter file:              The file to lint with this linter.
    /// - parameter configuration:     The SwiftLint configuration to apply to this linter.
    /// - parameter cache:             The persisted cache to use for this linter.
    /// - parameter compilerArguments: The compiler arguments to use for this linter if it is to execute analyzer rules.
    public init(file: SwiftLintFile,
                configuration: Configuration = Configuration.default,
                cache: LinterCache? = nil,
                compilerArguments: [String] = []) {
        self.file = file
        self.cache = cache
        self.configuration = configuration
        self.compilerArguments = compilerArguments

        let rules = configuration.rules.filter { rule in
            if compilerArguments.isEmpty {
                return !(rule is any AnalyzerRule)
            }
            return rule is any AnalyzerRule || rule is SuperfluousDisableCommandRule
        }
        self.rules = rules
        self.isCollecting = rules.contains(where: { $0 is any AnyCollectingRule })
    }

    /// Returns a linter capable of checking for violations after running each rule's collection step.
    ///
    /// - parameter storage: The storage object where collected info should be saved.
    ///
    /// - returns: A linter capable of checking for violations after running each rule's collection step.
    public func collect(into storage: RuleStorage) -> CollectedLinter {
        DispatchQueue.concurrentPerform(iterations: rules.count) { idx in
            let rule = rules[idx]
            let ruleID = type(of: rule).identifier
            CurrentRule.$identifier.withValue(ruleID) {
                rule.collectInfo(for: file, into: storage, compilerArguments: compilerArguments)
            }
        }
        return CollectedLinter(from: self)
    }
}

/// Represents a file that can compute style violations and corrections for a list of rules.
///
/// A `CollectedLinter` is only created after a `Linter` has run its collection steps in `Linter.collect(into:)`.
public struct CollectedLinter {
    /// The file to lint with this linter.
    public let file: SwiftLintFile
    private let rules: [any Rule]
    private let cache: LinterCache?
    private let configuration: Configuration
    private let compilerArguments: [String]

    fileprivate init(from linter: Linter) {
        file = linter.file
        rules = linter.rules
        cache = linter.cache
        configuration = linter.configuration
        compilerArguments = linter.compilerArguments
    }

    /// Computes or retrieves style violations.
    ///
    /// - parameter storage: The storage object containing all collected info.
    ///
    /// - returns: All style violations found by this linter.
    public func styleViolations(using storage: RuleStorage) -> [StyleViolation] {
        getStyleViolations(using: storage).0
    }

    /// Computes or retrieves style violations and the time spent executing each rule.
    ///
    /// - parameter storage: The storage object containing all collected info.
    ///
    /// - returns: All style violations found by this linter, and the time spent executing each rule.
    public func styleViolationsAndRuleTimes(using storage: RuleStorage)
        -> ([StyleViolation], [(id: String, time: Double)]) {
            getStyleViolations(using: storage, benchmark: true)
    }

    private func getStyleViolations(using storage: RuleStorage,
                                    benchmark: Bool = false) -> ([StyleViolation], [(id: String, time: Double)]) {
        guard !rules.isEmpty else {
            // Nothing to validate if there are no active rules!
            return ([], [])
        }

        if let cached = cachedStyleViolations(benchmark: benchmark) {
            return cached
        }

        let regions = file.regions()
        let superfluousDisableCommandRule = rules.first(where: {
            $0 is SuperfluousDisableCommandRule
        }) as? SuperfluousDisableCommandRule
        let validationResults: [LintResult] = rules.parallelMap {
            $0.lint(file: file, regions: regions, benchmark: benchmark,
                    storage: storage,
                    superfluousDisableCommandRule: superfluousDisableCommandRule,
                    compilerArguments: compilerArguments)
        }
        let undefinedSuperfluousCommandViolations = undefinedSuperfluousCommandViolations(
            regions: regions, configuration: configuration,
            superfluousDisableCommandRule: superfluousDisableCommandRule)

        let violations = validationResults.flatMap(\.violations) + undefinedSuperfluousCommandViolations
        let ruleTimes = validationResults.compactMap(\.ruleTime)
        var deprecatedToValidIdentifier = [String: String]()
        for (key, value) in validationResults.flatMap(\.deprecatedToValidIDPairs) {
            deprecatedToValidIdentifier[key] = value
        }

        if let cache, let path = file.path {
            cache.cache(violations: violations, forFile: path, configuration: configuration)
        }

        for (deprecatedIdentifier, identifier) in deprecatedToValidIdentifier {
            Issue.renamedIdentifier(old: deprecatedIdentifier, new: identifier).print()
        }

        // Free some memory used for this file's caches. They shouldn't be needed after this point.
        file.invalidateCache()

        return (violations, ruleTimes)
    }

    private func cachedStyleViolations(benchmark: Bool = false) -> ([StyleViolation], [(id: String, time: Double)])? {
        let start = Date()
        guard let cache, let file = file.path,
              let cachedViolations = cache.violations(forFile: file, configuration: configuration) else {
            return nil
        }

        var ruleTimes = [(id: String, time: Double)]()
        if benchmark {
            // let's assume that all rules should have the same duration and split the duration among them
            let totalTime = -start.timeIntervalSinceNow
            let fractionedTime = totalTime / TimeInterval(rules.count)
            ruleTimes = rules.compactMap { rule in
                let id = type(of: rule).identifier
                return (id, fractionedTime)
            }
        }

        return (cachedViolations, ruleTimes)
    }

    /// Applies corrections for all rules to this file, returning performed corrections.
    ///
    /// - parameter storage: The storage object containing all collected info.
    ///
    /// - returns: All corrections that were applied.
    public func correct(using storage: RuleStorage) -> [String: Int] {
        if let violations = cachedStyleViolations()?.0, violations.isEmpty {
            return [:]
        }

        if file.parserDiagnostics.isNotEmpty {
            queuedPrintError(
                "warning: Skipping correcting file because it produced Swift parser errors: \(file.path ?? "<nopath>")"
            )
            queuedPrintError(toJSON(["diagnostics": file.parserDiagnostics]))
            return [:]
        }

        var corrections = [String: Int]()
        for rule in rules.compactMap({ $0 as? any CorrectableRule }) {
            // Set rule context before checking shouldRun to allow file property access
            let ruleCorrections = CurrentRule.$identifier.withValue(type(of: rule).identifier) { () -> Int? in
                guard rule.shouldRun(onFile: file) else {
                    return nil
                }
                return rule.correct(file: file, using: storage, compilerArguments: compilerArguments)
            }
            if let corrected = ruleCorrections, corrected != 0 {
                corrections[type(of: rule).description.identifier] = corrected
                if !file.isVirtual {
                    file.invalidateCache()
                }
            }
        }
        return corrections
    }

    /// Formats the file associated with this linter.
    ///
    /// - parameter useTabs:     Should the file be formatted using tabs?
    /// - parameter indentWidth: How many spaces should be used per indentation level.
    public func format(useTabs: Bool, indentWidth: Int) {
        let formattedContents = try? file.file.format(trimmingTrailingWhitespace: true,
                                                      useTabs: useTabs,
                                                      indentWidth: indentWidth)
        if let formattedContents {
            file.write(formattedContents)
        }
    }

    private func undefinedSuperfluousCommandViolations(regions: [Region],
                                                       configuration: Configuration,
                                                       superfluousDisableCommandRule: SuperfluousDisableCommandRule?
        ) -> [StyleViolation] {
        guard regions.isNotEmpty, let superfluousDisableCommandRule else {
            return []
        }

        let allCustomIdentifiers =
            (configuration.rules.first { $0 is CustomRules } as? CustomRules)?
            .configuration.customRuleConfigurations.map { RuleIdentifier($0.identifier) } ?? []
        let allRuleIdentifiers = RuleRegistry.shared.list.allValidIdentifiers().map { RuleIdentifier($0) }
        let allValidIdentifiers = Set(allCustomIdentifiers + allRuleIdentifiers + [.all])
        let superfluousRuleIdentifier = RuleIdentifier(SuperfluousDisableCommandRule.identifier)

        return regions.flatMap { region in
            region.disabledRuleIdentifiers.filter({
                !allValidIdentifiers.contains($0) &&
                !region.disabledRuleIdentifiers.contains(.all) &&
                !region.disabledRuleIdentifiers.contains(superfluousRuleIdentifier)
            }).map { id in
                StyleViolation(
                    ruleDescription: type(of: superfluousDisableCommandRule).description,
                    severity: superfluousDisableCommandRule.configuration.severity,
                    location: region.start,
                    reason: superfluousDisableCommandRule.reason(forNonExistentRule: id.stringRepresentation)
                )
            }
        }
    }
}

private extension SwiftLintFile {
    var isEmpty: Bool {
        contents.isEmpty || contents == "\n"
    }
}
